浅析并发编程模型——Actor模型
7
星期四
2021年1月
验“金”室
多线程编程是每个程序员的基本功,同时也是开发中的难点,处理各种“锁”的问题是让人十分头痛的一件事。例如,设计一个转账功能,怎么保证在多线程下能正常运行?你可能会说,这个简单,在进行转账操作前,先对两个账户加锁,再在两个账户加上synchronized关键字就行了(代码如图1所示)。
图1
其实上面这段代码是有问题的,可能发生程序死锁的情况,如线程1先获取账户A的锁,等待获取账户B的锁,同时,线程2也在做转账操作,先获取了账户B的锁,等待账户A的锁,于是就出现了死锁的情况(如图2所示)。
图2
是不是觉得多线程编程真是太难了,到处是陷阱,一不小心就掉坑里面了?那除了加锁,有没有其他的处理方法呢?答案是:有,就是下文要介绍的Actor模型(Actor model)。
一、Actor模型简介
Actor模型,在1973由Carl Hewitt定义,被Erlang OTP推广,其消息传递更加符合面向对象的原始意图。Actor模型属于并发组件模型,通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念。
Actor模型是一个通用的并发编程模型,而非某个语言或框架所有,几乎可以用在任何一门编程语言中,如Erlang在语言层面支持Actor模型,典型应用如RabbitMQ,Akka是java版本的实现,Spark的底层通信模型就是使用Akka实现的Actor模型。
Actor模型的基础就是消息传递,一个Actor模型可以认为是一个基本的计算单元,它能接收消息并基于消息执行运算,也可以发送消息给其他Actor模型。各个Actor模型之间相互隔离,不共享内存。
Actor模型本身封装了状态和行为,在进行并发编程时,Actor模型只需要关注消息和其本身。而消息是一个不可变对象,所以Actor模型不需要去关注锁和内存原子性等一系列多线程常见的问题。
Actor模型由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:
1.状态
Actor模型中的状态指Actor对象的变量信息,状态由Actor模型自己管理,避免了并发环境下的锁和内存原子性等问题。
2.行为
Actor模型中的计算逻辑,通过Actor模型接收到的消息来改变Actor模型的状态。
3.邮箱
邮箱是Actor和Actor之间的通信桥梁,邮箱内部通过FIFO(先入先出)消息队列来存储发送方Actor的消息,接收方Actor再从邮箱队列中获取消息(如图3所示)。
图3 Actor概念模型
按消息的流向可以看出,可以将Actor模型分为发送方和接收方,一个Actor模型既可以是发送方也可以是接收方。另外,我们可以了解到Actor模型是串行处理消息的,Actor中消息不可变。
二、Actor模型的特点
1.实现了更高级的抽象
Actor模型类似面向对象编程(OO)中的对象,每个Actor模型实例封装了自己相关的状态,并且和其他Actor处于物理隔离状态。举一个游戏玩家的例子,每个玩家在Actor模型系统中是Player这个Actor的一个实例,每个player都有自己的属性,比如ID、昵称、攻击力等,体现到代码级别其实和面向对象编程的代码并无多大区别,在系统内存级别方面也出现了多个面向对象编程的实例。
2.无锁
在使用Java/C#等语言进行并发编程时需要特别关注锁和内存原子性等一系列线程问题,而Actor模型内部的状态由它自己维护,即它内部数据只能由它自己修改(通过消息传递来进行状态修改),所以使用Actors模型进行并发编程可以很好地避免这些问题。Actor模型内部是以单线程的模式来执行的,类似于redis,所以Actor模型完全可以实现分布式锁及类似的应用。
3.异步
每个Actor模型都有一个专用的MailBox来接收消息,这也是Actor模型实现异步的基础。当一个Actor模型实例向另外一个Actor模型发消息的时候,并非直接调用Actor的方法,而是把消息传递到对应的MailBox里,就好像邮递员,并不是把邮件直接送到收信人手里,而是放进每家的邮箱,这样邮递员就可以快速地进行下一项工作。所以在Actor模型系统里,Actor模型发送一条消息是非常快的(如图4所示)。
图4
这样设计的主要优势就是解耦了Actor,数万个Actor并发的运行,每个actor都以自己的步调运行,且发送消息、接收消息都不会被阻塞。
4.隔离
每个Actor模型的实例都维护这自己的状态,与其他Actor模型实例处于物理隔离状态,并非像多线程+锁模式那样基于共享数据。Actor模型通过消息的模式与其他Actor进行通信,与面向对象编程式的消息传递方式不同,Actor模型之间消息的传递是真正物理上的消息传递。
5.天生分布式
每个Actor模型实例的位置透明,无论Actor地址是在本地还是在远程机器上,对于代码来说都是一样的。每个Actor的实例非常小,最多几百字节,所以单机几十万的Actor的实例很轻松。如果你写过golang代码,就会发现其实Actor在重量级上很像Goroutine。由于位置的透明性,所以Actor系统可以随意横向扩展来应对并发,对于调用者来说,调用的Actor的位置就在本地,当然这也得益于Actor系统强大的路由系统。
6.生命周期
每个Actor模型实例都有自己的生命周期,就像java中的GC机制一样,对于需要淘汰的Actor模型,系统会销毁并释放内存等资源来保证系统的持续性。其实在Actor模型系统中,Actor模型的销毁完全可以手动干预,或者做到系统自动化销毁。
7.容错
说到Actor模型的容错,不得不说还是挺令人意外的。传统的编程方式是在将来可能出现异常的地方去捕获异常来保证系统的稳定性,这就是所谓的防御式编程。但是防御式编程也有自己的缺点,类似于现实中防御的一方永远不能防御住所有将来可能出现的代码缺陷。比如在java代码中很多地方充斥着判断变量是否为null,这些就属于防御式编码最典型的案例。但是Actor模型的程序并不进行防御式编程,而是遵循“任其崩溃”的哲学,让Actor模型的管理者们来处理这些崩溃问题。比如一个Actor模型崩溃之后,管理者可以选择创建新的实例或者记录日志,每个Actor模型的崩溃或者异常信息都可以反馈到管理者那里,这就保证了Actor模型系统在管理每个Actor模型实例的灵活性。
三、Actor模型应用场景
1.多线程并发应用
由于Actor模型系统的执行模型是单线程,并且异步,所以凡是有资源竞争类似功能的应用都非常适合使用Actor模型,比如秒杀活动,下面以前文提到的转账为例子进行说明。
首先,得有两个Actor,这两个Actor表示了两个账户,分别用账户A Actor(以下简称“账户A”)和账户B Actor(以下简称“账户B”)表示,再引入一个转账Actor,用于协调账户A Actor和账户B Actor两个Actor(如图5所示)。转账Actor收到转账50元的消息,向账户A发送取出50元的消息,向账户B发出存入50元的消息。然后账户A和账户B收到消息,进行处理。每个Actor都是独立的个体,它们之间靠消息交互就行了,根本不用锁。
图5
需要考虑的一个问题是,在转账过程中,如果有别的线程也对账户A进行了操作,导致账户A内余额不足,出现异常,但是账户B继续存入50元,那这个转账就出错了,这时就需要Actor支持事务处理(这里不详细描述)。
2.物联网平台应用
Actor模型非常适用于高并发场景,提供了一种高级抽象,能够简化在并发(Concurrency)/并行(Parallelism)应用场景下的编程开发,提供了异步非阻塞的、高性能的事件驱动编程模型,超级轻量级事件处理(每GB堆内存几百万Actor)。开源物联网平台Thingsboard就是采用Actor模型实现高并发处理设备数据接入。
Thingsboard将所有的功能进行划分,每个功能采用一个Actor模型实现,Actor模型的特性又允许每个Actor模型之间可以互相交换信息但又相互独立,并且支持多Actor模型并发处理,同时Actor模型本身支持容错处理,一个Actor模型异常后可以重启另一个相同的Actor模型处理后续内容,不会因为一个Actor模型的失败导致系统崩溃。
通过测试看来,4节点(3个Cassandra数据库节点、1个服务节点)的Thingsboard服务集群可以处理每小时1亿的数据量,同时Thingsboard的节点数量还可以进行扩展以支持更多海量数据(如图6所示)。
图6
当然,Actor模型还有其他很多应用场景。如并发流式处理,开源流处理框架Flink就是通过Actor模型实现的分布式通信,甚至我们系统中的定时任务也是通过Actor模型实现的。总之,Actor模型为我们提供了更高层次的并发抽象模型,让我们不必关心底层的实现,只需关注实现业务逻辑。Actor模型在一些并发场景中是很值得尝试的一种方案。
往期推荐
FCC30+
长按左边二维码
关注我们不迷路